42서울의 ft_putnbr_fd를 구현해보자!

우리가 쓸 수 있는 함수는 #unistd.h의 write 함수이다!
일단 write 함수에 대해 알아보자!!
ssize_t write(int fd, const void *buf, size_t count);
fd는 파일 디스크립터이다. buf는 쓰려는 데이터가 저장된 버퍼의 포인터이다.count는 버퍼에서 쓰려는 데이터의 바이트 수이다.write 함수의 사용 예시를 들어 더 자세히 설명하겠다.
#include <unistd.h>
int main(void)
{
const char *msg = "Hello, hyoyoon"; //내 42서울 별명(?)이다.
write(1, msg, 14); // 표준 출력으로 msg를 14바이트 쓴다.
return (0);
} //해당 main을 실행하면 콘솔창에 Hello, hyoyoon이 나온다.
위의 예시만 보아도 const char *msg는 메모리에 저장되는 것을 알 수 있다. 근데 왜 버퍼라고 하는 것일까??
메모리 : 컴퓨터에서 데이터를 저장하고 처리하기 위한 공간 전체, 프로그램이 실행되는 동안 모든 종류의 데이터를 저장하는 데 사용되며 매우 포괄적인 개념이다.
버퍼 : 메모리의 특정 부분을 가리키며 데이터를 임시로 저장하고 전송하기 위해 사용되는 공간을 의미한다.
여기서 먼저 버퍼의 필요성에 대해 알아보자!
버퍼는 주로 입출력 작업의 효율성과 성능을 개선하기 위해서이다.
입출력 작업은 일반적으로 CPU가 메모리에서 데이터를 처리하는 속도보다 훨씬 느리다.
예를 들어, 디스크에 데이터를 쓰거나 네트워크를 통해 데이터를 전송하는 속도는 메모리에서 데이터를 읽고 쓰는 속도보다 훨씬 느리다.
이로 인해 다음과 같은 문제들이 발생할 수 있다!
속도 불일치: CPU와 메모리의 처리 속도는 매우 빠른 반면 디스크나 네트워크 장치는 상대적으로 느리다. 만약 CPU가 데이터를 디스크로 바로 쓴다면 디스크가 데이터를 처리할 때까지 CPU는 대기해야 한다. 이는 시스템의 전반적인 성능 저하를 초래한다.
시스템 호출 오버헤드: 파일이나 소켓에 데이터를 쓸 때마다 시스템 호출(write)을 수행해야 한다. 시스템 호출은 사용자 모드에서 커널 모드로의 전환이 필요하기 때문에 비용이 많이 드는 작업이다. 따라서 데이터를 조금씩 자주 전송하는 대신 데이터를 모아서 한 번에 전송하는 것이 효율적이다.
이 데이터를 모으는 공간을 버퍼라고 한다. CPU는 데이터를 빠르게 버퍼에 쓰고 나서 다른 작업을 계속 수행할 수 있으며 디스크나 네트워크 장치는 자신의 속도에 맞춰 버퍼에 저장된 데이터를 처리한다.
사실 우리는 일상생활에서 버퍼와 관련된 단어를 자주 사용하고 있다.
바로 버퍼링이다. 버퍼링은 우리가 인터넷에서 영상을 볼 때 일시적으로 데이터를 저장해서 네트워크 속도의 변동이나 일시적인 지연에도 영상을 끊김없이 볼 수 있도록 한다. 물론 해당 상황의 버퍼는 사용자 경험을 최적화하기 위한 것이긴 하다.
이렇듯 버퍼는 데이터 전송을 위한 공간이라는 의미가 있으므로 역할에 대한 의미를 뜻하기 위해 가운데 인자를 buf라고 칭하는 것이다.
그럼 이제 본론으로 돌아 와서 ft_putnbr_fd를 구현해보자!
해당 과제를 처음 접했을 때 내가 처음 write를 사용할 때는 int는 4바이트이니 인자 count 값을 4로 해서 출력하면 되겠지 했다.
아래가 그 예시다!
#include <unistd.h>
int main(void)
{
int a = 10;
write(1, &a, 4);
return (0);
}
근데 이를 실행하면 줄바꿈 문자 하나만 나온다...
왜 그런가에 대해 알아보니 이는 이진 데이터가 터미널에 전달되면서 터미널은 해당 데이터를 문자로 해석하려고 하기 때문이다.
우선 10의 경우 little-endian 시스템에서는 메모리에 다음과 같이 저장된다.
0x0A 00 00 00 (16진수 표현)
이를 2진수로 표현하면:
00001010 00000000 00000000 00000000
메모리 주소: [0x00] [0x01] [0x02] [0x03]
값 (16진수): 0x78 0x56 0x34 0x12
메모리 주소: [0x00] [0x01] [0x02] [0x03]
값 (16진수): 0x12 0x34 0x56 0x78
이 두 방식의 장점과 단점, 사용 사례 및 적용은 아래 표에 정리해 두었다.
| 구분 | Little-endian | Big-endian |
|---|---|---|
| 장점 | - 편리한 데이터 접근: 낮은 주소부터 하위 바이트를 저장, 작은 크기의 데이터 타입 처리에 유리 | - 직관성: 메모리에서 숫자를 사람이 읽는 방식과 동일하게 볼 수 있어, 디버깅과 데이터 분석에서 직관적 |
| - 산술 연산의 효율성: 작은 단위의 데이터를 더 자주 처리할 때 효율적, 하위 비트부터 연산하는 프로세서 구조와 잘 맞음 | - 네트워크 표준과의 일관성: 네트워크 바이트 순서가 big-endian을 사용, 데이터 전송 시 변환 작업이 필요 없음 | |
| 단점 | - 호환성 문제: 네트워크 표준인 big-endian 방식과의 호환성 문제로 바이트 순서 변환 작업이 필요 | - 산술 연산의 비효율성: 하위 바이트부터 처리하는 연산에서 메모리의 높은 주소에서 시작, 추가적인 주소 계산이 필요할 수 있음 |
| - 덜 직관적: 사람이 읽기에 덜 직관적, 메모리 덤프에서 데이터를 읽을 때 바이트 순서를 뒤집어야 해 해석이 어렵다 | - 편의성 부족: 하위 비트를 자주 사용하는 프로세서 구조에서 직관적이지 않고, 하위 비트를 읽거나 쓸 때 번거로울 수 있음 | |
| 사용 사례 및 적용 | - 대부분의 현대적인 CPU 아키텍처, 특히 x86 계열(인텔, AMD 등)이 사용 | - IBM 메인프레임, 일부 네트워크 장비, RISC 아키텍처(예: PowerPC, SPARC 등)에서 사용 |
아무튼 다시 돌아와서
00001010 00000000 00000000 00000000
이 값은 write 함수가 출력하려고 하는 데이터이다.
그러면 각각의 ASCII 코드로 변환하면 줄바꿈, NULL, NULL, NULL이기에 줄바꿈만 나오는 것이다.
그러면 10을 출력하기 위해서는 어떻게 해야하는가????
문자로 '1'을 출력하고 '0'을 출력하면 된다...!
#include <unistd.h>
int main(void)
{
char tens_digit = '1';
char units_digit = '0';
write(1, &tens_digit, 1);
write(1, &units_digit, 1);
return (0);
}
이를 활용해서 정수를 표현할 때는 재귀함수를 활용하기로 했다. 재귀함수의 가장 중요한 점은 종료 조건을 잘 주어야 한다! 이를 기반으로 아래와 같은 코드를 작성했다!
추가로 코드 밑에 예시를 들어 설명을 추가했으므로 같이 보면 이해가 더 잘 될 거 같다!
static void positive(int n, int fd)
{
char d; //숫자 n의 1의 자리를 저장할 변수
if (0 <= n && n < 10) //만약 n이 0이상 9이하이면
{
d = n + '0'; //n을 문자로 바꾸고
write(fd, &d, 1); //출력하고
return ; //함수를 종료한다.
}
positive(n / 10, fd); //위의 if 조건을 만족하지 않았을 경우에는 재귀적으로 호출한다.
d = n % 10 + '0';
write(fd, &d, 1);
}
static void negative(int n, int fd)
{
char d;
if (-10 < n && n <= 0)
{
d = -1 * n + '0';
write(fd, &d, 1);
return ;
}
negative(n / 10, fd);
d = -1 * (n % 10) + '0';
write(fd, &d, 1);
}
void ft_putnbr_fd(int n, int fd)
{
write(fd, "-", n < 0); //n이 음수이면 '-'을 출력 아니면 count가 0이므로 출력 안함
if (n < 0)
negative(n, fd); //음수일 때는 negative helper func 실행
else
positive(n, fd); //양수일 때는 positive helper func 실행
}
ft_putnbr_fd 함수 설명 및 실행 과정주어진 코드는 정수를 파일 디스크립터(fd)에 출력하는 ft_putnbr_fd 함수와 이를 보조하는 positive와 negative라는 두 개의 재귀 함수로 구성되어 있다.
아래에 123과 -123을 예로 들어 각 값이 어떻게 출력되는지 단계별로 설명하겠다!
123이 ft_putnbr_fd에 전달될 때ft_putnbr_fd(123, fd) 실행 흐름write(fd, "-", n < 0); n < 0가 false(0)이므로 "-" 문자열은 출력되지 않는다.positive(123, fd) 호출 n이 양수이므로 positive(123, fd)를 호출한다.positive(123, fd) 실행 흐름Step 3: positive(123, fd)
123이 0 <= n < 10 조건에 맞지 않으므로, 재귀적으로 positive(12, fd)를 호출한다.Step 4: positive(12, fd)
12도 0 <= n < 10 조건에 맞지 않으므로, 재귀적으로 positive(1, fd)를 호출한다.Step 5: positive(1, fd)
1은 0 <= n < 10 조건을 만족하므로, d = '1'으로 설정하고 write(fd, &d, 1);을 실행하여 1을 출력한다. Step 6: 돌아가서 positive(12, fd) 실행
d = n % 10 + '0';에서 d = '2'로 설정되고, write(fd, &d, 1);을 실행하여 2를 출력한다.Step 7: 돌아가서 positive(123, fd) 실행
d = n % 10 + '0';에서 d = '3'로 설정되고, write(fd, &d, 1);을 실행하여 3을 출력한다.결과: 123이 순서대로 출력된다.
-123이 ft_putnbr_fd에 전달될 때ft_putnbr_fd(-123, fd) 실행 흐름write(fd, "-", n < 0); n < 0가 true(1)이므로 "-" 문자열이 출력된다.negative(-123, fd) 호출 n이 음수이므로 negative(-123, fd)를 호출한다.negative(-123, fd) 실행 흐름Step 3: negative(-123, fd)
-123이 -10 < n <= 0 조건에 맞지 않으므로, 재귀적으로 negative(-12, fd)를 호출한다.Step 4: negative(-12, fd)
-12도 -10 < n <= 0 조건에 맞지 않으므로, 재귀적으로 negative(-1, fd)를 호출한다.Step 5: negative(-1, fd)
-1은 -10 < n <= 0 조건을 만족하므로, d = '1'으로 설정하고 write(fd, &d, 1);을 실행하여 1을 출력한다. Step 6: 돌아가서 negative(-12, fd) 실행
d = -1 * (n % 10) + '0';에서 d = '2'로 설정되고, write(fd, &d, 1);을 실행하여 2를 출력한다.Step 7: 돌아가서 negative(-123, fd) 실행
d = -1 * (n % 10) + '0';에서 d = '3'로 설정되고, write(fd, &d, 1);을 실행하여 3을 출력한다.결과: -123이 순서대로 출력된다.