An interface to a device driver is called a File in Windows (adopted from Unix-like conventions). To communicate with the serial port, we can call the CreateFile() function from the Windows API [1].

HANDLE CreateFile(
  LPCSTR lpFileName,                   // pointer to name of the file
  DWORD dwDesiredAccess,         // access (read-write) mode
  DWORD dwShareMode,              // share mode
  LPSECURITY_ATTRIBUTES lpSecurityAttributes,
                                                        // pointer to security attributes
  DWORD dwCreationDisposition,  // how to create
  DWORD dwFlagsAndAttributes,   // file attributes
  HANDLE hTemplateFile               // handle to file with attributes to copy
);

[1] https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea

The Windows API documentation concisely describes the file name parameter to be:

LPCWSTR lpFileName; 	//The name of the file or device to be created or opened

Here, you can see that the Windows API is a bit of a rabbit hole. Fortunately, the Windows API is (mostly) backwards compatible to the MS-DOS days, but unfortunately this means that an understanding of these seemingly pre-historic operating systems is in order; further, to understand the operating systems, it is helpful to understand the history of the coding conventions as well.

In this example and in modern times, the name “COM5” most likely refers to a Serial (RS-232) over USB device where the device driver creates "virtual COM" port. This name is more specifically referred to as the MS-DOS device name, which is a symbolic link in the object manager with a name of the form \DosDevices\DosDeviceName [2].

When formatting the argument, it is advised to use the user-mode convention to avoid accidently creating the File in Kernel-mode. For reference: In user-mode, the executing code has no ability to directly access hardware or reference memory. Code running in user mode must delegate to system APIs to access hardware or memory. Due to the protection afforded by this sort of isolation, crashes in user mode are always recoverable. In Kernel mode, the executing code has complete and unrestricted access to the underlying hardware. Crashes in kernel mode are catastrophic; they will halt the entire PC [3].
In Windows, there are reserved names for files; these include: COM1-COM9 & LPT1-LPT9 [4].
Therefore, simply passing “COM3” as the name, could inadvertently open the port (create the file) in Kernel-mode. To explicitly open/create in user-mode, and to enable the access of ports named “COM9” and higher, the argument can be formatted as follows:

int nPort = 11;
wchar_t lpFileName[16];
wsprintf(lpFileName, L"\\\\.\\COM%d", nPort);

[2] https://docs.microsoft.com/en-us/windows-hardware/drivers/kernel/introduction-to-ms-dos-device-names
[3] https://stackoverflow.com/questions/1311402/what-is-the-difference-between-user-and-kernel-modes-in-operating-systems
[4] https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file

When deciding on a read/write strategy for the communications, it is helpful to think about the size and frequency of the messages being passed. This can provide helpful guidance on weather to use a Synchronous or an Asynchronous strategy. While these terms are used consistently throughout the MS documentation, synonymous terms being blocking and non-blocking are used in other libraries.

For completeness: In synchronous file I/O (blocking), a thread starts an I/O operation and immediately enters a wait state until the I/O request has completed. Whereas a thread performing Asynchronous file I/O (non-blocking) sends an I/O request to the kernel by calling an appropriate function. If the request is accepted by the kernel, the calling thread continues processing another job until the kernel signals to the thread that the I/O operation is complete. It then interrupts its current job and processes the data from the I/O operation as necessary [5].

[5] https://docs.microsoft.com/en-us/windows/win32/fileio/synchronous-and-asynchronous-i-o

For simplicity, it is advised to push thread management as high in the architecture as possible, thus a synchronous model should be used to start. Then, if asynchronous functions are needed, the library can be expanded. Thus our create file call looks as follows:

m_hIDComDev = CreateFile(
   lpFileName,							//MS-DOS device name
   GENERIC_READ | GENERIC_WRITE,	//access mode
   0,									//share mode - prevent shared access
   NULL,								//security
   OPEN_EXISTING,					//action to take (do not create)
   FILE_ATTRIBUTE_NORMAL,			//normal flag, omit Asynchronous opperation mode flag
   NULL								//parameter ignored with OPEN_EXISTING action
);


Configuring port timeouts:

COMMTIMEOUTS CommTimeOuts;					//WinBase.h - struct holding timeout info for comms
CommTimeOuts.ReadIntervalTimeout = 0xFFFFFFFF;		//The maximum time allowed to elapse before the arrival of the next byte on 
												//	the communications line, in milliseconds. (zero is no timeouts)
CommTimeOuts.ReadTotalTimeoutMultiplier = 0;			//The multiplier used to calculate the total time-out period for read 
												//	operations, in milliseconds. For each read operation, this value 
												//	is multiplied by the requested number of bytes to be read.
CommTimeOuts.ReadTotalTimeoutConstant = 0;			//Allow extra time for read opp to complete
CommTimeOuts.WriteTotalTimeoutMultiplier = 0;			//The multiplier used to calculate the total time-out period for write operations, in milliseconds.
CommTimeOuts.WriteTotalTimeoutConstant = 5000;		//A constant used to calculate the total time-out period for write operations, 
												//	in milliseconds. For each write operation, this value is added to the 
												//	product of the WriteTotalTimeoutMultiplier member and the number of bytes to be written.
SetCommTimeouts(m_hIDComDev, &CommTimeOuts);	//commit timeouts to Comms device file


Configuring port settings:

DCB dcb;
dcb.DCBlength = sizeof(DCB);
GetCommState(m_hIDComDev, &dcb);		//Retrieves the current settings
dcb.BaudRate = CBR_115200;				//reset baud rate
dcb.ByteSize = 8;							//reset byte size
SetCommState(m_hIDComDev, &dcb);		//commit settings
SetupComm(m_hIDComDev, 1024, 1024);		//recomend buffer sizes in bytes (characters)


In Synchronous-mode, writing to a file is straight forward:

char msg[] = "hello world...";
int length = 15;
WriteFile(
	m_hIDComDev, 
	msg, 
	length, 
	&dwBytesWritten, 
	NULL
);


In Synchronous-mode, reading too is straight forward:

DWORD dwBytesRead = 0;
DWORD dwBytesToRead, dwErrorFlags;
COMSTAT ComStat;
ClearCommError(m_hIDComDev, &dwErrorFlags, &ComStat);	//get number of bytes on port
dwBytesToRead = ComStat.cbInQue;						//get number of bytes to read
if (!ReadFile(m_hIDComDev, buffer, dwBytesToRead, &dwBytesRead, NULL)) {
	DWORD _error = GetLastError();


While the read itself is straight forward, the characters must typically be parsed, particularly if the COM device is streaming data continuously. Thus, care must be taken to separate individual messages; again, typically there is a separation character (or termination character) such as a carriage return (\r), line feed(\n), or both (\r\n).

For a device that streams data continuously: we need to extract messages from between termination characters using a rolling buffer.

The following is simplified example where a device is queried and a response message is captured and parsed to a floating point number:


Main.cpp

#include "alx_hardware_serial.h"
int main()
{
	alx::hardware::Serial serial;
	double _valueOut = -999.999; //bogus number as error
	//Open COM 13
	int nPort = 13;
	serial.open(nPort, 115200, 1024);
	//get reading from device
	serial.flush();
	char requestCommand[11] = "!001:SYS?\r";
	serial.write(requestCommand, 11);
	bool _continue = true;
	char _data[1024] = {};
	int _bytesReadTotal = 0;
	int _bytesReadSingle = 0;
	while (_continue) {
		if (serial.read(_data + _bytesReadTotal, 1024 - _bytesReadTotal, &_bytesReadSingle) != 0)
			return (-1);
		_bytesReadTotal += _bytesReadSingle;
		char* _termChar = strchr(_data, '\r');
		if (_termChar != NULL)
			_continue = false;
		//std::cout << _data << std::endl;
		//parse values
	}try {
		_valueOut = atof(_data);
	}
	catch (...) {
		std::cout << "ERROR in atof" << std::endl;
	}
	return (0);
}


alx_hardware_serial.h

#include <Windows.h>
namespace alx {
	namespace hardware {

		class Serial {
		public:
			Serial();
			~Serial();
			int open(int nPort, int nBaud, int szBuffer);
			int close();
			int flush();
			int write(char* msg, int length);
			int inWaiting(DWORD* _bytesInQueue);
			int read(char* buffer, int szBuffer, int* bytesRead);
		protected:
			HANDLE m_hIDComDev;				//reference to the createed comms device
		};

	}
}


alx_hardware_serial.cpp

#include "alx_hardware_serial.h"
namespace alx {
	namespace hardware {
		Serial::Serial()
		{
			m_hIDComDev = NULL;
		}
		Serial::~Serial()
		{
			close();  //close port to release resource
		}
		int Serial::open(int nPort, int nBaud, int szBuffer) {
			if (m_hIDComDev != NULL)
				return (-1);	//port is already open
			//format COM port id string
			wchar_t lpFileName[16];
			wsprintf(lpFileName, L"\\\\.\\COM%d", nPort);							//ex: build "COM3" string (wide character format)
			//    use  \\.\COM15  for user-mode & to access ports numbered higher than COM9

			//CreateFile()  Creates or opens a file or I/O device - returns handle to the created file (device in our case)
			//	LPCWSTR lpFileName, 							The name of the file or device to be created or opened
			//	DWORD dwDesiredAccess, 							The requested access to the file or device
			//	DWORD dwShareMode,								The requested sharing mode of the file or device
			//														0x00000000	Prevents other processes from opening a file 
			//														or device if they request delete, read, or write access.
			//	LPSECURITY_ATTRIBUTES lpSecurityAttributes,		A pointer to a SECURITY_ATTRIBUTES structure that contains 
			//														two separate but related data members; when NULL use default security descriptor
			//	DWORD                 dwCreationDisposition,	An action to take on a file or device that exists or does not exist
			//														OPEN_EXISTING: Opens a file or device, only if it exists. If the 
			//														specified file or device does not exist, the function fails 
			//														and the last - error code is set to ERROR_FILE_NOT_FOUND(2).
			//	DWORD                 dwFlagsAndAttributes,		The file or device attributes and flags:
			//														FILE_ATTRIBUTE_NORMAL: The file does not have other attributes set
			//														FILE_FLAG_OVERLAPPED: The file or device is being opened or created for asynchronous I / O.
			//															When subsequent I/O operations are completed on this handle, 
			//															the event specified in the OVERLAPPED structure will be set 
			//															to the signaled state. 	If this flag is specified, the file 
			//															can be used for simultaneous read and write operations.
			//	HANDLE                hTemplateFile				When opening an existing file, CreateFile ignores this parameter.
			m_hIDComDev = CreateFile(
				lpFileName,						//MS-DOS device name
				GENERIC_READ | GENERIC_WRITE,	//access mode
				0,								//share mode - prevent shared access
				NULL,							//security
				OPEN_EXISTING,					//action to take (do not create)
				FILE_ATTRIBUTE_NORMAL,			//normal flag as minimimum argument, omit Asynchronous opperation mode flag
				NULL							//parameter ignored with OPEN_EXISTING action
			);
		
			if (m_hIDComDev == NULL) 
				return (-2);	//port cannot open

			COMMTIMEOUTS CommTimeOuts;									//WinBase.h - struct holding timeout info for comms
			CommTimeOuts.ReadIntervalTimeout = 0xFFFFFFFF;				//The maximum time allowed to elapse before the arrival of the next byte on 
																		//	the communications line, in milliseconds. (zero is no timeouts)
			CommTimeOuts.ReadTotalTimeoutMultiplier = 0;				//The multiplier used to calculate the total time-out period for read 
																		//	operations, in milliseconds. For each read operation, this value 
																		//	is multiplied by the requested number of bytes to be read.
			CommTimeOuts.ReadTotalTimeoutConstant = 0;					//Allow extra time for read opp to complete
			CommTimeOuts.WriteTotalTimeoutMultiplier = 0;				//The multiplier used to calculate the total time-out period for write operations, in milliseconds.
			CommTimeOuts.WriteTotalTimeoutConstant = 5000;				//A constant used to calculate the total time-out period for write operations, 
																		//	in milliseconds. For each write operation, this value is added to the 
																		//	product of the WriteTotalTimeoutMultiplier member and the number of bytes to be written.
			SetCommTimeouts(m_hIDComDev, &CommTimeOuts);				//commit timeouts to Comms device file


			DCB dcb;
			dcb.DCBlength = sizeof(DCB);
			GetCommState(m_hIDComDev, &dcb);		//Retrieves the current settings
			dcb.ByteSize = 8;

			switch (nBaud) {
			case 9600:
				dcb.BaudRate = CBR_9600; break;
			case 115200:
				dcb.BaudRate = CBR_115200; break;
			default:
				dcb.BaudRate = CBR_9600;
			}

			//commit dcb settings to Device and test for success, if not close resources and return false
			//SetCommState();		//commit settings
			//SetupComm();			//recomend buffer sizes in bytes (characters)
			if (!SetCommState(m_hIDComDev, &dcb) ||
				!SetupComm(m_hIDComDev, szBuffer, szBuffer)
				)
			{
				DWORD dwError = GetLastError();
				CloseHandle(m_hIDComDev);
				return (-3); //configure failed
			}
			return (0);
		}
		int Serial::close()
		{
			if (m_hIDComDev == NULL) 
				return (-1);
			CloseHandle(m_hIDComDev);
			m_hIDComDev = NULL;
			return (0);
		}
		int Serial::flush() {
			if (m_hIDComDev == NULL)
				return (-1);
			if (!PurgeComm(m_hIDComDev, PURGE_RXABORT | PURGE_RXCLEAR | PURGE_TXABORT | PURGE_TXCLEAR))
				return (-2);
			return (0);
		}
		int Serial::write(char* msg, int length) {
			DWORD dwBytesWritten;
			if(!WriteFile(m_hIDComDev, msg, length, &dwBytesWritten, NULL))
				return (-1);
			return (0);
		}
		int Serial::inWaiting(DWORD* _bytesInQueue) {
			//The number of bytes received by the serial provider but not yet read by a ReadFile operation.
			if (m_hIDComDev == NULL)
				return (-1);

			//ClearCommError()
			//   Retrieves information about a communications error 
			//   and reports the current status of a communications device in ComStat.

			//ComStat   Contains information about a communications device.
			//cbInQue	The number of bytes received by the serial provider but not yet read by a ReadFile operation.
			//cbOutQue	The number of bytes of user data remaining to be transmitted for 
			//				all write operations.This value will be zero for a nonoverlapped write.

			DWORD dwErrorFlags;
			COMSTAT ComStat;
			if (!ClearCommError(m_hIDComDev, &dwErrorFlags, &ComStat))
				return (-1);

			*_bytesInQueue = ComStat.cbInQue;
			return 0;
		}
		int Serial::read(char* buffer, int szBuffer, int* bytesRead) {
			if (m_hIDComDev == NULL)
				return (-1);
			DWORD dwBytesRead = 0;
			DWORD dwBytesToRead, dwErrorFlags;
			COMSTAT ComStat;
			
			//ClearCommError()
			//   Retrieves information about a communications error 
			//   and reports the current status of a communications device in ComStat.

			//ComStat   Contains information about a communications device.
			//cbInQue	The number of bytes received by the serial provider but not yet read by a ReadFile operation.
			//cbOutQue	The number of bytes of user data remaining to be transmitted for 
			//				all write operations.This value will be zero for a nonoverlapped write.

			*bytesRead = 0;
			ClearCommError(m_hIDComDev, &dwErrorFlags, &ComStat);		//get number of bytes on port
			if (!ComStat.cbInQue) 
				return (0);							//if zero -> return

			dwBytesToRead = ComStat.cbInQue;		//get number of bytes to read
			if (szBuffer < (int)dwBytesToRead)		//verify that buffer is not over run
				dwBytesToRead = (DWORD)szBuffer;

			//ReadFile()	Reads data from the specified file or input/output (I/O) device.
			//	HANDLE       hFile,					device
			//	LPVOID       lpBuffer,				A pointer to the buffer that receives the data read from a file or device.
			//										This buffer must remain valid for the duration of the read operation.
			//										The caller must not use this buffer until the read operation is completed.
			//	DWORD        nNumberOfBytesToRead,	The maximum number of bytes to be read.
			//	LPDWORD      lpNumberOfBytesRead,	set to zero when fuction is first called, and incremented as bytes are read
			//	LPOVERLAPPED lpOverlapped			structure containg pointer to event flags - hEvent.
			//If the function succeeds, the return value is nonzero (TRUE).
			//	To get extended error information, call the GetLastError function.

			if (!ReadFile(m_hIDComDev, buffer, dwBytesToRead, &dwBytesRead, NULL)) {
				DWORD _error = GetLastError();
				return (-2);	//read failed
			}
			*bytesRead = dwBytesRead;
			return (0);
		}
	}
}