The pipe() system call allows you to get file descriptors (one for reading and one for writing) for a channel (a pipe) that allows to stream bytes through multiple processes. This is an example where a parent process creates a pipe and its child writes to it so the parent can read from it:
int main() {
    int fd[2];
    pipe(fd);
    int pid = fork();
    if (pid == 0) { // Child:
        close(fd[0]); // Close reading descriptor as it's not needed
        write(fd[1], "Hello", 5);
    } else { // Parent:
        char buf[5];
        close(fd[1]); // Close writing descriptor as it's not needed
        read(fd[0], buf, 5); // Read the data sent by the child through the pipe
        write(1, buf, 5); // print the data that's been read to stdout
    }
}
When a shell encounters the pipe (|) operator, it does use the pipe() system call, but also does additional things, in order to redirect the left operand's stdout and the right operand's stdin to the pipe. Here's a simplified example of what the shell would do for the command echo "hi" | ./a.out (keep in mind that when duplicating a file descriptor it gets duplicated to the first index available in the open files structure of the process):
int main() {
    int fd[2];
    pipe(fd);
    int pid_echo = fork();
    if (pid_echo == 0) {
        // Close reading descriptor as it's not needed
        close(fd[0]);
        // Close standard output
        close(1);
        // Replace standard output with the pipe by duplicating its writing descriptor
        dup(fd[1]);
        // Execute echo;
        // now when echo prints to stdout it will actually print to the pipe
        // because now file descriptor 1 belongs to the pipe
        execlp("echo", "echo", "hi", (char*)NULL);
        exit(-1);
    }
    int pid_aout = fork();
    if (pid_aout == 0) {
        // Close standard input
        close(0);
        // Replace standard input with the pipe by duplicating its reading descriptor
        dup(fd[0]);
        // Execute a.out;
        // Now when a.out reads from stdin it will actually read from the pipe
        // because now file descriptor 0 belongs to the pipe
        execl("./a.out", "./a.out", (char*)NULL);
        exit(-1);
    }
}