bdfgdfg

응용계층 패킷 구조 (패딩 비트) 본문

CS/TCP IP

응용계층 패킷 구조 (패딩 비트)

marmelo12 2022. 1. 4. 17:31
반응형

패딩 비트

struct Test
{
public:
    int a;
    char b;
};
int main()
{
    Test t1;
    std::cout << sizeof(t1.a) << std::endl;
    std::cout << sizeof(t1.b) << std::endl;
    std::cout << sizeof(t1) << std::endl;
}

위의 Test 구조체는 크기가 어떻게 될까.

a는 int형이니 4바이트 b는 char형이니 1바이트.

다 합치면 5바이트가 나올거 같지만 실제로 실행해보면

실제로는 8바이트가 나온다. 왜 그럴까?

 

구조체의 크기는 사실 멤버중 크기가 가장 큰 자료형을 기준으로 배수만큼 커진다.

즉 구조체의 크기는 변수의 크기에 패딩 비트가 더해지는데

 -> 패딩 비트란 구조체 크기를 가장 큰 자료형 기준으로 배수만큼 만들어주기 위해 추가되는 비트.

 int형 4byte
char형 1byte 남는 공간 (padding 3 bytes)

 

이렇게 대부분의 컴파일러는 CPU가 접근하기 쉬운 메모리 위치에 필드를 배치시키기에 위와 같은 패딩비트가

자동으로 들어가게 된다.

 

일반적인 프로그래밍을 할때에 위와 같은 문제는 그닥 와닿지 않지만 네트워크 환경에서는 매우 중요하다.

 -> 1바이트 메모리 하나하나를 끊어서 읽어들여야 하므로.

 

이걸 해결하기 아주 쉬운 방법이 있는데 전처리 지시문중 하나인 #pragma pack을 이용하는 것

#pragma pack(push,1)
struct Test
{
	int index;
	char abc;
 	char abcd;
};
#pragma pack(pop)

위의 경우 8바이트가 나오는게 맞지만 (패딩비트가 적용된다면)

#pragma pack을 이용하면

6바이트가 나온다.

 

 

패킷 구조

TCP프로토콜 통신의 특징에서 데이터의 경계가 없다는 특징이 존재한다.

그렇기에 데이터를 send하거나 recv할 때 (특히 recv) 그냥 문자열을 한 소켓에 여러번 보내면 받는 입장에서 recv를 할때 끊어서 읽는게 아닌(전송할 때의 byte) 한번에 다 읽어들일 수 있고 그렇지 않을수도 있다.

그렇기에 중요한게 응용계층간의 패킷 프로토콜이 필요하다.

 

먼저 패킷의 구조를 잡아보자.

#define PACKET_HEADER_SIZE 4
#define PACKET_BUF_SIZE 1024

struct PACKET_HEADER
{
	WORD m_len;  // 패킷 크기
	WORD m_type; // 패킷 타입(프로토콜)
};

struct DEFAULT_PACKET
{
	PACKET_HEADER m_ph;				// 패킷 헤더
	char	      m_dataField[PACKET_BUF_SIZE];	// 패킷 데이터 영역.
};

위와 같이 패킷의 구조를 잡았는데

먼저 패킷의 상단부분에 해당하는 PACKET_HEADER.

PACKET_HEADER는 이 패킷의 크기와 어떤 타입인지(이 패킷이 로그인 요청처리인지..등등)를 식별하기 위함이다.

 -> 적어도 recv를 하고 수신된 byte의 크기는 PACKET_HEADER보다는 커야한다.

 

단순히 2바이트/2바이트로 나누어 크기와 타입을 저장하고, 그 뒤의 영역부터는 data를 저장하는 dataField영역.

 

이제 위의 DEFAULT_PACKET 구조체를 이용하여 Packet 클래스를 만들어 기능을 추가해본다.

// 데이터를 삽입하는 연산자 << 오버로딩.
// 데이터를 출력하는 연산자 >> 오버로딩
class Packet
{
public:
	char*			m_readPos; // 현재 데이터를 읽을 위치.
	char*			m_writePos; // 현재 데이터를 쓸 위치.
	DEFAULT_PACKET		m_packet;
private:
	void	Add(const BYTE* pData, UINT size);
	void	Get(const BYTE* pData, UINT size);

	// cout처럼 연속적으로 사용하기 위해 반환이 참조(자기자신)여야 한다.
public:
	Packet& operator << (int data);
	Packet& operator << (long data);
	Packet& operator << (short data);
	Packet& operator << (float data);
	Packet& operator << (BYTE data);
	Packet& operator << (char* data);
	Packet& operator << (DWORD data);
	Packet& operator << (std::string data);
public:
	Packet& operator >> (int& data);
	Packet& operator >> (long& data);
	Packet& operator >> (short& data);
	Packet& operator >> (float& data);
	Packet& operator >> (BYTE& data);
	Packet& operator >> (char* data);
	Packet& operator >> (DWORD& data);
	Packet& operator >> (std::string& data);
public:
	Packet();
	~Packet();
};

중요한것은 m_writePos와 m_readPos.

 

m_writePos는 Packet에 데이터를 실어 보내기 위해서 Packet의 dataField에 데이터를 쓰기위한 위치 주소를 기억한다.

m_readPos는 Packet에 실린 데이터를 가져오기위해 Packet의 dataField의 다음에 읽어야 할 위치 주소를 기억한다.

 

그 행위를 쉽게하기 위해서 <<연산자와 >>연산자를 오버로딩 하였는데

std::cout << 1234 << "hihihi" << 10.4f;

위와 같이 데이터를 밀어넣기에 쉽고 직관적인 방법을 채택.

 

먼저 생성자 부분을 보면

Packet::Packet() : m_readPos(m_packet.m_dataField),m_writePos(m_packet.m_dataField)
{
    ::ZeroMemory(&m_packet, sizeof(DEFAULT_PACKET));
    m_packet.m_ph.m_len = PACKET_HEADER_SIZE;
}

readPos와 writePos의 위치를 dataField의 첫번째 offset위치로 옮겨준다.

그리고 packet의 메모리를 초기화 해주고, 패킷 크기는 PACKET_HAEDER_SIZE를 미리 더해준다.

 

그 다음 Add와 Get함수.

void Packet::Add(const BYTE* pData, UINT size)
{
    m_packet.m_ph.m_len += size; // 패킷크기(길이)
    ::CopyMemory(m_writePos, pData, size); // 데이터 삽입.
    m_writePos += size; // 위치 옮기기.

}

void Packet::Get(const BYTE* pData, UINT size)
{
    // pData에 데이터를 집어넣는다.
    m_packet.m_ph.m_len -= size;
    ::CopyMemory((void*)pData, m_readPos, size);
    m_readPos += size;
}

Add부분을 보자. <<연산을 통해 집어넣으려는 데이터의 크기는 두번째 매개변수인 size를 통해 들어온다.

그리고 실제 데이터는 pData에 담겨 들어오는데.

먼저 packet의 크기를 size만큼 더해준 후 m_writePos가 가르키고 있는 위치(데이터를 써야할 곳)에 데이터를 넣어준다.

그런 다음 마지막으로 m_writePos를 size만큼 더해주어 다음에 써야할 공간을 가르키게 한다.

 

Get도 똑같다.

>> 연산을 통해 패킷의 데이터 field에 저장된 데이터를 꺼내오는 작업이다.

크기는 두번째 매개변수인 size를 통해 들어온다.

pData의 주소에 꺼내온 데이터를 넣어주게 되며, 패킷의 크기를 size만큼 줄인 후 pData에 현재 m_readPos가 가리키는 곳에서 size만큼 읽어와 pData에 복사한다.

그런 다음 마지막으로 m_readPos를 size만큼 더해주어 다음에 읽어야할 공간을 가르키게 한다.

 

여기서 m_readPos와 m_writePos가 버퍼의 크기를 넘어가기 위한 별도의 처리는 없지만

원형큐와 비슷하게 m_readPos와 m_writePos가 겹치면 더이상 데이터가 없다는 뜻이므로 다시 dataField의 첫번째 offset으로 옮긴다거나. m_writePos위치에서 써야할 데이터의 size가 버퍼의 크기를 넘긴다면 m_readPos부터 m_writePos까지를 data Field의 첫번째 offset으로 복사한다는등 여러가지 방법이 존재.

 

Packet& Packet::operator<<(int data)
{
    Add((BYTE*)&data, sizeof(int));
    return *this;
}

Packet& Packet::operator<<(long data)
{
    Add((BYTE*)&data, sizeof(long));
    return *this;
}

Packet& Packet::operator<<(short data)
{
    Add((BYTE*)&data, sizeof(short));
    return *this;
}

Packet& Packet::operator<<(float data)
{
    Add((BYTE*)&data, sizeof(float));
    return *this;
}

Packet& Packet::operator<<(BYTE data)
{
    Add(&data, sizeof(BYTE));
    return *this;
}

Packet& Packet::operator<<(char* data) // 문자열
{
    int size = strlen(data);
    Add((BYTE*)data, size + 1); // 널문자 추가!
    return *this;
}

Packet& Packet::operator<<(DWORD data)
{
    Add((BYTE*)&data, sizeof(DWORD)); // 널문자 추가!
    return *this;
}

Packet& Packet::operator<<(std::string data)
{
    Add((BYTE*)data.c_str(), data.size() + 1);
    return *this;
}

Packet& Packet::operator>>(int& data)
{
    // data에 데이터를 넣어준다.
    Get((BYTE*)&data, sizeof(int));
    return *this;
}

Packet& Packet::operator>>(long& data)
{
    Get((BYTE*)&data, sizeof(long));
    return *this;
}

Packet& Packet::operator>>(short& data)
{
    Get((BYTE*)&data, sizeof(short));
    return *this;
}

Packet& Packet::operator>>(float& data)
{
    Get((BYTE*)&data, sizeof(float));
    return *this;
}

Packet& Packet::operator>>(BYTE& data)
{
    Get((BYTE*)&data, sizeof(BYTE));
    return *this;
}

Packet& Packet::operator>>(char* data)
{
    int len = strlen(m_readPos) + 1; // 널문자 포함. (strlen은 널문자를 만나면 반환)
    Get((BYTE*)data, len);
    return *this;
}

Packet& Packet::operator>>(DWORD& data)
{
    Get((BYTE*)&data, sizeof(DWORD));
    return *this;
}

Packet& Packet::operator>>(std::string& data)
{
    int len = strlen(m_readPos) + 1;
    Get((BYTE*)data.c_str(), len);
    return *this;
}

나머지 연산자 오버로딩 처리부분.

나머지는 쉽지만 문자열 처리부분에서 널문자를 포함하기 위해 +1하는것을 잊어버리면 안된다.

 -> strlen은 널문자를 포함하지 않은 길이.

그리고 문자열을 뽑아올 때 m_readPos의 길이를 얻어와서 헷갈릴 수 있지만,

패킷 type에 맞는 구조체들이 정해졌을 때 읽는 순서가 정해지고. 문자열을 읽어야하는 경우가 오면

m_readPos는 문자열의 시작주소를 가르키게 된다. 즉 HelloWorld라는 문자열을 읽을 때 m_readPos는 H의 시작주소를 가르키게 되고, 문자열은 널문자까지이므로 strlen호출이 가능한 것.

 

 

결과.

#include "Packet.h"

int main()
{
   
    Packet p;

    std::string s1, s2;
    int a;
    float b;
    p << "hihihi" << 10 << 10.4f << "kerororor";
    p >> s1 >> a >> b >> s2;

    std::cout << s1.c_str() << " " << a << " " << b << " " << s2.c_str();
    //::WSACleanup();
    return 0;
}

반응형
Comments