Efficiently read a file into a (C) string using POSIX APIs
This is a good project, one that could be very useful in practice. I’ve had two or three C++ blogs over the years, and every time I spin up a new one, I include an updated version of a very old post I wrote about reading an entire file into a string. It’s not an easy problem to solve efficiently, for a number of complicated reasons. Eschewing the standard library and going directly to underlying POSIX API is not a bad idea; one of the sources of inefficiency is the fact that the standard library will buffer everything, meaning you end up with an unnecessary “middle man” between your code and the actual file data.
I am not an expert in the POSIX APIs, so take everything I say in that area with a grain of salt. However, the algorithm itself looks okay; I don’t see anything wrong with it. There are a few things I think are probably unnecessary and useless, but nothing that introduces potential dangers. I’ll get into details about the algorithm as we come to them in the code.
Normally I would start with a design review, but… well, frankly, there doesn’t really look like there’s much in the way of design here. Your focus appears to be on the reading algorithm, and very little else. Which… not a good thing. That’s actually the opposite of how you write good code. And it shows here. To word it another way, the actual file-reading code is probably fine… but to actually use it in practical code (the way it’s currently written) would be difficult, at best.
For example, the iterators return char const*, and there looks like a lot of effort to NUL-terminate the data. So… is it meant to be string data? If so, why isn’t there a c_str() function? Or a conversion to string_view? On the other hand, if it’s not meant to be a string, just raw byte data read from a file, why not use std::byte? Why isn’t there a data() function?
Better yet, why not just read the data directly into an existing container, like std::string (for text data) or std::vector&lt;std::byte&gt; (for binary data)?
But I get it; your focus was on the read algorithm, not making an actually useful interface. That’s fine for experimentation, but when you’re eventually putting this code to use in a real-world library, the place that really needs focus is the public interface… not the low-level reading code. If your public interface sucks, then even if the reading code is the most brilliant code ever, the whole library still sucks for the user. But if your public interface is really good, then even if the reading code sucks… well, you can fix that under the hood, and users of your public interface won’t notice (except for maybe seeing performance gains).
Also, you should really get into the habit of testing your code. And by that I don’t mean “write it, then write a main() function that tries it out and if it all compiles and appears to run and produce correct-looking output, that’s that”. I mean fully unit-testing the code.
So, let’s get into the actual code.
Code review
Slurp.hh
    void *begin_{nullptr};
    void *end_;

The convention in C++ is to put all the type information together. In other words:

void *end_;: This is C style.
void* end_;: This is C++ style. (Well, other than that we try to avoid void* in C++.)

There is an alternative design to consider. Rather than holding two pointers, it might make more sense to hold a pointer and a size. Why? It makes it much easier to maintain and check the class invariant. If you use a pair of pointers, you have to make sure that end_ comes after begin_. But there is no portable way to ensure that (comparing random pointers is UB). On the other hand, if you use a pointer and a size, there is no possible way that the “end” can be before the beginning (assuming the size is unsigned, which is standard practice, but even if it’s signed, it’s trivial to check that the size is greater than zero).
It’s not really a big deal, and two pointers does work just fine in practice. It’s just something I’ve seen experts have opinions on.
I also don’t get why these are void*. void* makes a lot less sense in C++ than it does in C, and in this case… I can’t see the point of it at all. If this is supposed to be character data—a string—then why aren’t these char*? That sure seems to be the intention, what with all the NUL terminating and casting in begin() and end(). If this is supposed to be binary data, then unsigned char* or, better, std::byte* make more sense. void* just doesn’t make sense, not least because you’re just casting it to char* before using it anyway.
Anywho, aside from all that, there doesn’t seem to be a reason why end_ couldn’t also have an initializer.
    Slurp() = delete;

This line is technically unnecessary, because the fact that you have the constructor Slurp(char const[]) means that you have already disabled the default constructor.
Still, it doesn’t hurt, and it does express intent, so I’d leave it.
    Slurp(Slurp&amp;) = delete;
    Slurp &amp;operator=(Slurp) = delete;

These will probably “work”, but if your intention is to disable copying, then they are malformed. They should be:
    Slurp(Slurp const&amp;) = delete;
    auto operator=(Slurp const&amp;) -&gt; Slurp&amp; = delete;
    // or:
    //  Slurp&amp; operator=(Slurp const&amp;) = delete;

Disabling copying makes sense, given that copying will probably be fairly expensive. If someone really wants to copy the data, they can always do something like:
auto const data = Slurp{&quot;input.data&quot;};

auto const data_copy = std::vector(data.begin(), data.end());

But you might consider making the type movable. That could be very handy. It wouldn’t even be hard to do; you just need to make the class aware of a “null” or moved-from state, perhaps like so:
class Slurp
{
private:
    void* begin_ = nullptr;
    void* end_ = nullptr;

public:
    [[gnu::nonnull]] explicit Slurp(char const* path)
    {
        // your existing constructor code...
    }

    Slurp(Slurp&amp;&amp; other) noexcept
    {
        std::ranges::swap(begin_, other.begin_);
        std::ranges::swap(end_, other.end_);
    }

    ~Slurp()
    {
        if (begin_) // note the if test
            std::free(begin_);
    }

    auto operator=(Slurp&amp;&amp; other) noexcept -&gt; Slurp&amp;
    {
        std::ranges::swap(begin_, other.begin_);
        std::ranges::swap(end_, other.end_);
        return *this;
    }

    Slurp(Slurp const&amp;) = delete;
    auto operator=(Slurp const&amp;) -&gt; Slurp&amp; = delete;
};

Being able to very cheaply moved the slurped data around could be very handy indeed.
    [[gnu::nonnull]] Slurp(const char path[]);

Writing functions that look like they take arrays like this is a really bad idea. (Except for main(), which is just special in a lot of ways.) I’ve lost count of the number of newbies who think that this actually takes an array argument, and then think they can do sizeof(path) / sizeof(path[0]) to get the number of elements. Yes, you and I and other experts know what’s really going on, but you shouldn’t be writing code that’s only readable by experts. Down that path lies madness.
On the other hand Slurp(const char* path) or Slurp(char const* path) are unlikely to confuse beginners, and, really, tell the truth of what’s going on much better.
As a general rule, all constructors that take a single argument should be marked explicit (unless you have a damn good reason not to). Yes, explicit should really be the default, but that ship sailed a long time ago.
In addition to this constructor, it would help usability if you could use strings or—even better—actual paths with this class. It would be trivial to do, too:
explicit Slurp(std::string const&amp; path) :
    Slurp{path.c_str()}
{}

explicit Slurp(std::filesystem::path const&amp; path) :
    Slurp{path.c_str()}
{}

Now this next bit is going to get really complicated. Brace yourself.
gnu::const on non-static member functions
    [[gnu::const, gnu::returns_nonnull]] const char *begin() const noexcept;

The issue here the gnu::const attribute. I am not a GCC expert, but from my understanding of gnu::const, it sure seems like using it for a member function is an absolutely terrible, and dangerous, idea.
Here’s why.
gnu::const means that a function’s output depends ONLY on the function’s arguments. And I mean ONLY. The function’s behaviour cannot be affected by anything else; it doesn’t matter what state the rest of the program happens to be in (well, I mean, the state has to be valid… if the abstract machine’s state is kerfuckt, then all bets are off in any case), the function’s return value will ALWAYS be the same, given the same arguments.
This makes sense for, say, a function like: auto twice(int i) { return i * 2; }. It doesn’t matter what’s going on with the rest of the program, like how many exceptions are in flight or how many files are open or how much memory has been allocated. So long as the function can complete without UB (for example, there is no stack overflow), then it will ALWAYS return the same result for the same arguments. twice(32) will ALWAYS return 64. ALWAYS. GCC can use that information to optimize. For example, if you call twice(14) a million times in your program, GCC might cache the result of the first call, and then replace every other call with just loading that cached value.
So far none of this sounds problematic, I suppose. Well, now let’s talk about non-static member functions.
There is a very important key feature of non-static class member functions: the implicit this.
You see, every non-static class member function has a hidden this argument. We usually pretend it’s the first argument (but it doesn’t actually have to be, in reality, it’s usually passed in a specific register), so your begin() function actually looks like begin(Slurp const*):
// what you write:
auto Slurp::begin() const noexcept -&gt; char const*
{
    return begin_;
}

// what the compiler actually implements:
auto Slurp__begin(Slurp const* this) noexcept -&gt; char const*
{
    return this-&gt;begin_;
}

Normally, this isn’t something you need to think about.
However… remember what gnu::const means. It means that begin() will ALWAYS return the same result, when called with the same argument.
So let’s imagine you write this:
auto const s = Slurp{&quot;data.file&quot;};

auto p1 = s.begin();
auto p2 = s.begin();
auto p3 = s.begin();
auto p4 = s.begin();
auto p5 = s.begin();

The above is theoretically:
auto const s = Slurp{&quot;data.file&quot;};

auto p1 = Slurp__begin(&amp;s);
auto p2 = Slurp__begin(&amp;s);
auto p3 = Slurp__begin(&amp;s);
auto p4 = Slurp__begin(&amp;s);
auto p5 = Slurp__begin(&amp;s);

But since begin() is gnu::const, once we know the result of calling it with address of s, we can assume that will ALWAYS be the result of calling it with that address. So the compiler could do:
auto p1 = Slurp__begin(&amp;s);
auto p2 = p1;
auto p3 = p1;
auto p4 = p1;
auto p5 = p1;

(And it probably will.)
So far no problems. There is currently no way for the value of begin_ to change once a Slurp object has been constructed, so the above code is a perfectly valid optimization.
However, suppose you implemented moving—or copying, or any way to change the data in a Slurp object. Then you wrote this:
auto const s = Slurp{&quot;data.file&quot;};

auto p1 = s.begin();
auto p2 = s.begin();

s = Slurp{&quot;other.file&quot;};    // this line will construct a new Slurp,
                            // and REPLACE the internals of `s` with the
                            // new data

auto p3 = s.begin();
auto p4 = s.begin();

Now the address of s hasn’t changed… but it’s internal data—in particular, the value of begin_—has changed. The compiler sees the above code as:
auto const s = Slurp{&quot;data.file&quot;};

auto p1 = Slurp__begin(&amp;s);
auto p2 = Slurp__begin(&amp;s);

s = Slurp{&quot;other.file&quot;};

auto p3 = Slurp__begin(&amp;s);
auto p4 = Slurp__begin(&amp;s);

And it does the optimization:
auto const s = Slurp{&quot;data.file&quot;};

auto p1 = Slurp__begin(&amp;s);
auto p2 = p1;

s = Slurp{&quot;other.file&quot;};

auto p3 = p1;
auto p4 = p1;

Except after the move assignment, p1 is now a dangling pointer. Which means p3 and p4 are dangling pointers, too.
This is a disaster.
Now you may be thinking: “Meh, no problem; my class is non-copyable, non-movable, and immutable. This problem doesn’t affect me.”
Wanna bet?
Remember, gnu::const isn’t about the object. It’s about the arguments to the function. In the example above, the object’s internals changed, but the address of the object didn’t, so the argument to begin() didn’t either, and that was the problem. So if the object’s internals can’t possibly change… no problem, right?
Wrong.
Because even if it is impossible to change the internals of an object, it is still possible to create an object… destroy it… then create a whole new object at the same memory address… and it is the address that matters. Because that is what is being used as the function argument to begin().
Here’s just one example of how that could happen:
auto s = std::optional&lt;Slurp&gt;{std::in_place, &quot;data.file&quot;};

auto p1 = s-&gt;begin();
auto p2 = s-&gt;begin();

s.emplace(&quot;other.file&quot;);    // destroys contained Slurp object, then
                            // constructs a new one in the same place... so it
                            // has the same address

auto p3 = s-&gt;begin();
auto p4 = s-&gt;begin();

Which eventually optimizes to:
auto s = std::optional&lt;Slurp&gt;{std::in_place, &quot;data.file&quot;};

auto p1 = s-&gt;begin();
auto p2 = p1;

s.emplace(&quot;other.file&quot;);

auto p3 = p1;
auto p4 = p1;

And ends up with the same problem.
But you don’t even need std::optional! This problem could be triggered with even this code:
auto f()
{
    auto s = Slurp{&quot;data.file&quot;};

    auto p = s.begin();
}

auto some_other_function()
{
    f();
    f();
}

What might happen above is the first call to f() happens to place s at some address on the stack… then when f() ends, s is destroyed… then f() is called again and just happens to place the new s at the same address on the stack. But of course, the allocation done within the Slurp constructor (the posix_memalign() call) may be entirely different. So p SHOULD have two entirely different values with the two successive calls to f() even though the address of s (and thus, the argument passed to begin()) is the same for both calls. But gnu::const says it won’t; gnu::const says that if the argument is the same, the result is the same.
Once again: catastrophe.
To get a clearer view of why, imagine the calls to f() are inlined. Inlining isn’t necessary to trigger the problem, but it helps illustrate. If the calls are inlined, some_other_function() will look like:
auto some_other_function()
{
    {
        auto s = Slurp{&quot;data.file&quot;};

        auto p = s.begin();

        // s is destroyed here
    }

    {
        auto s = Slurp{&quot;data.file&quot;};

        auto p = s.begin();

        // s is destroyed here
    }
}

After the first s is destroyed, the second s may very well be constructed at the exact same location… let’s call it STACK_ADDRESS. So:
auto some_other_function()
{
    {
        auto s = Slurp{&quot;data.file&quot;};

        auto p = Slurp__begin(STACK_ADDRESS);

        // s is destroyed here
    }

    {
        auto s = Slurp{&quot;data.file&quot;};

        auto p = Slurp__begin(STACK_ADDRESS);

        // s is destroyed here
    }
}

The two calls to begin() are identical, and gnu::const promises that that means the result will be identical, so the compiler might do:
auto some_other_function()
{
    auto __p_inlined = static_cast&lt;char const*&gt;(nullptr);

    {
        auto s = Slurp{&quot;data.file&quot;};

        __p_inlined = Slurp__begin(STACK_ADDRESS);
        auto p = __p_inlined;

        // s is destroyed here
    }

    {
        auto s = Slurp{&quot;data.file&quot;};

        auto p = __p_inlined;

        // s is destroyed here
    }
}

And, boom.
Whew, that was a long digression. The tl;dr is “don’t use gnu::const for (non-static) member functions”… but I think it’s important to understand why.
And, really, it seems kinda pointless in modern C++. If it is possible for a function to be gnu::const, then it is almost certainly also possible for it to be constexpr… in which case the compiler can optimize it far more aggressively than it could possibly optimize a function that is merely gnu::const (but not wholly visible).
Slurp.cc
using namespace std;

Never, ever do this at file scope. No, not even in a non-header file. Just use std::.
#define ERROR_IF(CONDITION) do {\
        if (__builtin_expect((CONDITION), false))\
            throw system_error(errno, system_category());\
    } while (false)

#define ERROR_IF_POSIX(POSIX_PREFIXED_FUNCTION_CALL) do {\
        const int error_number = posix_##POSIX_PREFIXED_FUNCTION_CALL;\
        if (__builtin_expect(error_number, 0))\
            throw system_error(error_number, system_category());\
    } while (false)

Avoid macros. There’s no good reason for them here. Inlined functions will be just as efficient… not that it really matters, because these are error cases after all. For example:
namespace {

auto error_if(int result)
{
    if (result != 0) [[unlikely]]
        throw std::system_error{errno, std::system_category()};
}

auto error_if_posix(int error_number)
{
    if (error_number != 0) [[unlikely]]
        throw std::system_error{error_number, std::system_category()};
}

} // anonymous namespace

Slurp::Slurp(char const* path)
{
    // ... [snip] ...

    error_if_posix(::posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL));

    struct ::stat file_statistics{};
    error_if(::fstat(fd, &amp;file_statistics));

    // ... etc.

But even that’s not really great, because it just obfuscates the function’s logic. I don’t see how it’s really an improvement over:
Slurp::Slurp(char const* path)
{
    // ... [snip] ...

    if (auto const res = ::posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL); res != 0) [[unlikely]]
        throw std::system_error{res, std::system_category()};

    struct ::stat file_statistics{};
    if (auto const res = ::fstat(fd, &amp;file_statistics); res != 0) [[unlikely]]
        throw std::system_error{errno, std::system_category()};

    // ... etc.

The only thing I might do is maybe avoid repeating std::system_category by doing:
namespace {

auto posix_error(int err)
{
    return std::system_error{err, std::system_category()};
}

} // anonymous namespace

Slurp::Slurp(char const* path)
{
    // ... [snip] ...

    if (auto const res = ::posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL); res != 0) [[unlikely]]
        throw posix_error(res);

    auto file_statistics = ::stat{};
    if (auto const res = ::fstat(fd, &amp;file_statistics); res != 0) [[unlikely]]
        throw posix_error(errno);

    // ... etc.

I just don’t see the benefit of hiding the throws. Yes, I get that it is somewhat ugly to have to check the return code every function call… but this is a C API; that’s just the nature of the game.
If you really don’t want all that ugly C-ish code, the right move is to properly wrap the API… not to obfuscate your logic by hiding it behind macros. For example:
Slurp::Slurp(char const* path)
{
    auto const fd = posix::open(path, O_RDONLY);

    posix::fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL);

    auto const file_statistics = posix::fstat(fd);

    // ... etc.

(Note that implied is that each call above throws on failure, like a good C++ API should.)
But it’s really an all-or-nothing deal. Either completely wrap the API into a proper C++ API, so the usage just looks like regular C++… which maintainers of the code should be familiar with. Or use the C API as-is—don’t hide it behind macros—so people who actually know the POSIX API inside and out can look at your code and immediately spot what’s going on (and any potential problems). Going half-way just annoys both parties, with no real benefits.
    class FileDescriptor {
        const int fd_;
    public:
        FileDescriptor(int fd) : fd_{fd} {ERROR_IF(fd_ == -1);}
        ~FileDescriptor() {close(fd_);}
        [[gnu::const]] operator int() const noexcept {return fd_;}
    };

This is a really useful class! Rather than burying it as an implementation detail, it could be part of a public API. You’d need to flesh it out a bit (and make it non-copyable, but perhaps movable), but it’s pretty cool.
FileDescriptor fd{open(path, O_RDONLY)};

This goes back to the “never use using namespace std;” rule: function names like open() are extremely common, and there is a very high chance of conflict between the POSIX function, and some function you might put in your class in the future. If that were to happen, if you are lucky, you’ll get a compile error… but I’ve seen cases where behaviour subtly changes because the wrong function is called.
To make sure you get the right function, you should explicitly qualify it. In this case, because it’s a POSIX function that doesn’t exist in a namespace, you have to use ::open().
You should do this for all the POSIX functions.
constexpr char32_t NullChar = U'\0';

I’m a little baffled by the logic here.
Okay, so you want the data to be null-terminated. Fine, sure. But… you’re treating the data as an array of char… not char32_t. So… huh?
The only thing I can figure is that you’re imagining that maybe someone might possibly want to reinterpret_cast the char const* to char32_t const*, and treat the data as a UTF-32 NUL-terminated string. That seems a pretty odd thing to presume. And it’s even odder when you consider that the Slurp class doesn’t even model a NUL-terminated string in any sense. There is no c_str() function, for example. Even if there were, it’s hard to see why it should return a char32_t const* and not a char const*.
Aside from being odd, it may also be wrong. You are aligning the memory according to file_statistics.st_blksize… not alignof(char32_t). In other words, if you’re thinking that you can just reinterpret_cast the result of begin() to a char32_t const* and treat it as a UTF-32 string… you may be in for a nasty surprise when your program crashes due to a a misaligned read. Or, possibly, just garbled text (that is probably illegal Unicode). (And all this is assuming the endianness of the UTF-32 data is correct.)
I mean, if you really wanted to read file data as a UTF-32 string, why wouldn’t you read it into a std::u32string? Similarly, if you want a regular string, why not read it directly into a std::string? In either case, all the allocation and alignment headaches are taken care of, and the NUL termination is automatic. And if you want binary data, why not read directly into a std::vector&lt;std::byte&gt;?
I’ll be getting back to that idea later.
ERROR_IF_POSIX(memalign(&amp;begin_, blksize, bufsize));

Honestly, I don’t see the point of this.
Okay, I get that the file you’re reading probably has a preferred block size, and if you were mem-mapping the file, or reading it directly from storage, then yes, you’d need aligned memory. But you’re doing neither.
Internally, when you open the file (and specify the access characteristics via posix_fadvise()) a buffer gets created. (I think you can avoid this by using direct input, but that’s beyond the scope here. mmap() is another way to avoid the buffer, but that’s another beast altogether.) Then you are reading from that buffer into your program’s memory space. Now, the buffer needs to be aligned to the block size of the file, so that the file can be read efficiently a block at a time. But your memory… the memory you allocate to read the file data (from the buffer) into… that does not need to be aligned to the block size of the file… because with that memory, you are not reading directly from the file, you are reading memory-to-memory (from the kernel’s buffer, to your array).
Follow? Block-alignment only matters for the initial read of the file from the device. THAT needs to be block-aligned. But unless you’re doing direct input, that’s happening in the kernel (usually), and then read() copies from that buffer to your desired location. Once the data is already in RAM, it’s just a memory-to-memory copy… which is just a rep stosb or some SSE or AVX operation. That doesn’t need to be aligned.⁕
⁕ (Ugh, in order to make this not too absurdly long, I have to gloss over or outright ignore a lot of hyper-technical details, so I know there are nerds out there just champing at the bit to “akshually…” me in the comments. Okay, yes, the data does need to be aligned for super-fast memory-to-memory transfer… both the source and destination data, with penalties for not being aligned varying depending on your processor architecture. HOWEVER… the default C++ allocator in any serious-grade standard library is almost certainly already aligning every allocation to take advantage of those efficiencies by default. So unless you’re doing something “clever”, you don’t need to worry about it.)
In other words, you’re wasting your time using posix_memalign(). You gain nothing from it. You could just as well be using malloc() or new.
Which brings us back again to the idea of reading directly into std::string or std::vector&lt;std::byte&gt;. But let me postpone that for a bit more, because we’re almost at the end of the code, so let’s get through that first.
ERROR_IF_POSIX(madvise(begin_, bufsize, POSIX_MADV_SEQUENTIAL));

I’ll admit that I don’t understand how this call isn’t failing. I thought posix_madvise() was only for memory-mapped memory. I would have guessed you’d get a EBADF from trying to use it on random memory. TIL, I guess.
But I do suspect the call is just being ignored. My understanding of POSIX_MADV_SEQUENTIAL is that it signals to the kernel to make a larger look-ahead buffer… which makes sense if you’re mem-mapping a file into memory, but is just nonsense for RAM. (Where you gonna put the look-ahead buffer for RAM? Super-duper-RAM?)
ERROR_IF(read(fd, begin_, fsize) == -1);

read() can fail spuriously for a number of reasons, like a signal interrupting it, and end up doing only a partial read. You can’t just write a read() call like this; you need to call it in a loop and keep doing reads until you’ve read fsize bytes in total or until read() returns zero.
memcpy(end_, &amp;NullChar, sizeof NullChar); // ensure null termination

I mean… why not just std::fill_n(end, 4, 0);? It would probably be faster. (Unless your optimizer is smart enough to transform that memcpy() into a memset(), in which case it will probably be equally fast.)
Reading directly into containers
Okay, the biggest problem with Slurp is that it’s a specialized type. Even if it had a perfect interface, it’s still not a string. If I need a std::string, which is very common, then I’ll be forced to copy the entire Slurp object into a std::string. What a waste.
What if you just read the file’s contents directly into a std::string. Or, if it’s binary data, into a std::vector&lt;std::byte&gt;. Remember, there is no need to use posix_memalign() to specially align the memory to the original file’s preferred block size. The default allocator will align things just fine. So you can just use standard containers.
(But, FYI, even if the default allocator didn’t align things just fine, it would still be a better solution to use a custom allocator, and standard containers, rather that rolling your own, half-assed container.)
For example, to read into a string (note: untested, rough code):
auto read_into_string(std::filesystem::path const&amp; path)
{
    // file_descriptor is a class basically identical to yours
    auto const fd = file_descriptor{::open(path.c_str(), O_RDONLY)};

    // get the file size from the file descriptor
    auto const file_size = [&amp;]
    {
        struct ::stat fstats{};
        if (auto const res = ::fstat(fd, &amp;fstats); res != 0)
            throw std::system_error{errno, std::system_category()};
        return fstats.st_size;
    }();

    // create the string we'll be reading into
    auto s = std::string{};
    s.resize(file_size);

    // this is the read loop
    auto pos = std::string::size_type{};
    while (true)
    {
        auto const last_read = ::read(fd, s.data() + pos, s.size() - pos);
        if (last_read &lt; 0)
            throw std::system_error{errno, std::system_category()};
        else if (last_read == 0)
            break;

        // this should never happen
        if (last_read &gt; (s.size() - pos)) [[unlikely]]
            throw std::runtime_error{&quot;more data in file than file size&quot;};

        pos += last_read;
    }

    // this should never happen
    if (pos != s.size()) [[unlikely]]
        throw std::runtime_error{&quot;failed to read entire file&quot;};

    return s;
}

And the usage:
auto const string_data = read_into_string(&quot;file.txt&quot;);

And string_data is a std::string.
The only thing that I suspect might make this code slower than your code, and only for very, very large files, is that there is no way (AFAIK) to allocate a std::string without initializing its contents. In other words, s.resize(n) doesn’t does just allocate n bytes (internally), it also zeros those bytes… which is a total waste of time for us. You shouldn’t have this inefficiency if you’re reading binary data into a std::vector&lt;std::byte&gt; (for which the code is otherwise identical).
But even that std::string inefficiency should be tolerable (unless your files are huge), because you get the benefit of actually having an honest-to-goodness string. Which basically works everywhere, and which many APIs specifically need, in one form or another.
And, frankly, I seriously doubt you’ll even notice it, because, again, unless you’re dealing with huge files, the cost of that zeroing will be completely dwarfed by the I/O costs.
Anywho, this is getting way too long, and I’ve been sitting on it for days, so, I’ll just go ahead and publish what I’ve got.