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));
}
이제 왜 lint1가 Manual
이라는 변수가 사용되지 않았다고 투덜대는지 아시겠지요.
어떻게 동작하는가?
이제 실제로 전처리 과정이 어떻게 진행되는지 따라가 보겠습니다. 원래 코드는 이랬지요.
#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을 기억해 둡시다.
- 치환되는 시점에서 매크로 인자들은 미리 모두 처리가 됩니다. (
#
등으로 특수하게 처리되는 경우는 제외) 하지만 정작 매크로 몸체 자체는 인자를 치환하는 거 말고는 변하는 게 없습니다. - 일단 매크로가 치환되면, 치환된 몸체와 그 뒤의 코드로부터 더 이상 치환할 게 없을 때까지 계속 치환을 반복합니다.
두번째 규칙이 좀 마음에 걸리는군요. 감을 잡기 위해서 위의 코드를 처리해 봅시다. 먼저 첫번째 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 - ch
는 origch
가 대문자일 때 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 | 1
과 0x60 | 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과 같은 문자 집합의 문제인지 컴파일러의 문제인지가 확실하지 않음. "기계"라고 한 것으로 봐서 전자가 더 가능성이 높긴 함.
C 코드를 분석하고 문제가 될 수 있는 코드를 찾아 내는 유닉스 유틸리티. 예를 들어 초기화되지 않은 변수의 사용을 체크할 수 있습니다. ↩
C 표준을 찾아 보고 싶은 분들을 위해: 첫번째 규칙은 6.10.3.1장의 1항, 두번째 규칙은 6.10.3.4장의 1항을 따릅니다. 좀 더 자세한 사항은 6.10.3장 전체를 참고하시길 바랍니다. ↩
전처리 단계에서 무시되는 주석은 원래 코드에 박혀 있던 주석 뿐이고, 이들 주석은 전처리 전에 미리 없앨 수 있습니다. 사실 표준을 따르는 전처리기는 새로운 주석 토큰을 만들어내는 것 자체가 불가능하죠. ↩