There's a bigger reason for using it than merely convenience, it hides all of the quirks and oddities and non-portabilities and strange corner cases and who-knows-what else so you can focus on dealing with the target device, not debugging odd boundary conditions in the comms.
Except that the exact same point can be made the other way around. Modbus devices notoriously implement the protocol in whatever way they feel like. The risk of using a library is that it does not work with the device without modification into internals.
Modbus indeed is full of quirks. One of the simpler one is the drug-induced definition of register addresses, the original standard defines that "on paper" specified addresses are off by one from the "on wire" addresses (I forget which way, demonstrating how cumbersome such feature is). If everybody always followed strictly the standard, this would be just a small inconvenience, but because they don't, you have two types of addresses, and actually since you can't assume, off-by-one can happen in either direction. Therefore register address 12345 can actually mean 12344, 12345 or 12346. Without a library, you obviously choose which convention you follow (I follow the "on-wire" notation, even when it is nonstandard!), which instantly limits the choices from 3 to 2, and if the device specification happens to make a note whether the addresses are on-wire or on-paper, that's instantly down to 1! With libmodbus, you have to read through the documentation, so have one layer of uncertainty more. And because
they actually don't tell you (see example:
https://libmodbus.org/reference/modbus_write_register/ ), you are on your own.
So eventually, if all you
really needed to do is to read holding registers, you would have copy-pasted the C language CRC check function directly from the official standard document, and written the 5 remaining lines that set the n_registers etc. accordingly, like this (actual code from a random project of mine:)
static uint8_t rtu_read_req[8];
rtu_read_req[0] = slave_addr;
rtu_read_req[1] = func_code;
rtu_read_req[2] = (reg_addr&0xff00)>>8;
rtu_read_req[3] = (reg_addr&0x00ff)>>0;
rtu_read_req[4] = 0;
rtu_read_req[5] = n_regs;
uint16_t crc = crc16(rtu_read_req, 6);
rtu_read_req[6] = (crc&0x00ff)>>0;
rtu_read_req[7] = (crc&0xff00)>>8;
rs485_start_write_read(rtu_read_req, buf, sizeof rtu_read_req, rx_buf_len_needed, modbus_rs485_inst, modbus_baud, modbus_mode);
OK, it was more than 5 lines, sorry 'bout that.
But be my guest and by all mean port libmodbus to a microcontroller, or fix a random arduino project which does not work, so that you can use a third-party library where library is not needed, the pinnacle of cargo cult engineering. In real world, libraries carry overhead, which is why they are most useful when they do complicated things which can be abstracted to simple and intuitive interfaces. And 97% of the complexity of libmodbus is in managing serial devices (maybe through termios, interface of which sucks) and TCP sockets; something you don't have on a microcontroller at all in form compatible with libmodbus, so you would be redoing most of it anyway.
level of complexity required for a full implementation
Nice, but devices which implement it fully are rare. Probably some Modicon/now Schneider automation does, and of course any project actually using libmodbus would then be mostly compatible with libmodbus.
I have lately written drivers for various, mostly Chinese, solar PV inverters and none of them implement anything beyond bare minimum modbus, and worse, they use weird non-standard conventions. If you are a slave, you may want to implement a bit more to be compatible with masters too clever, but especially if you are a master, you can implement bare minimum that gets the job done.
Modbus is a low-quality spec, meaning it's followed ad-hoc in whatever way. It needs to be approached in a practical instead of perfectionist way.