There is no magic. When a C program is compiled, there are two major steps to it.
First, each individual compilation unit is compiled in is isolation. (A compilation unit is basically one .c file, plus everything it includes).
At this stage, it doesn't know anything about what's contained in other .c files, which means that it can't generate a full program. What it can do is generate code with a few "fill in the blanks" spots. If, from foo.c you call a function that is declared in bar.h, and defined in bar.c, then the compiler can only see that the function exists. It is declared in bar.h, so we have to assume that the full definition exists somewher. But because that definition is inside another compilation unit, we can't yet see it. So the compiler generates code to call the function, with a little note on it saying "fill in the address of this function once it's actually known".
Once every compilation unit has been compiled in this way, you are left with a bunch of object files (typically .o if compiled by GCC, and .obj if you use MSVC), containing this kind of "fill in the blanks" code.
Now the linker takes all these object files, and tries to merge them together, which allows it to fill in the blanks. The function we generated a call for above can now be found, so we can insert its address into the call.
So nothing special happens if a .c file has the same name as a .h. That's just a convention to make it easier for humans to figure out what's inside each file.
The compiler doesn't care. It just takes each .c file, plus anything it includes, and compiles it to an object file. And then the linker merges all these object files together into a single executable.