Most specifically, the problem was with my STDIN (hi). I mistakenly believed that STDIN was just the packet payload; however, it's actually expecting the entire packet.
Also, I discovered that sockaddr->protocol and sockaddr->ifindex (and perhaps sockaddr->hatype as well, but it's all zeros, and perhaps sockaddr->pkttype and sockaddr->halen as well, but they are both a single byte) are byte order reversed. I couldn't find any documentation to support that, but my experience validates that's what works.
So the socat command I'm trying to create is really just an interface to sendto(). And, as such, the buffer parameter (the entire Ethernet packet) is populated by, in this case, STDIN. The rest of the command line arguments are the rest of the sendto() parameters.
The final working raw socket SOCKET-SENDTO command is this:
$ echo -n -e "\xab\xcd\xef\x01\x23\x45\x01\x23\x45\xab\xcd\xef\x00\x02\x68\x69" | sudo socat - SOCKET-SENDTO:17:3:0:x0300x02000000x0000x00x06xabcdef0123450000
Usage:
echo
-e (enable interpretation of backslash escapes, each byte needs a \x)
-n (do not output the trailing newline)
"\xab\xcd\xef\x01\x23\x45 (dst_mac)
\x01\x23\x45\xab\xcd\xef (src_mac)
\x00\x02 (length of payload)
\x68\x69 (payload ("hi" in this case))
"
domain - 17 (PF_PACKET, from `procan -c`)
type - 3 (SOCK_RAW, from `procan -c`)
protocol - 0
address - (data representation of a sockaddr structure, only the beginning needs an 'x', the rest are optional)
sockaddr_ll
family - (without family)
protocol - x0300 (ETH_P_ALL (unsigned short), from if_ether.h)
ifindex - x02000000 (interface to use for sending, from /sys/class/net/eth0/ifindex (int))
hatype - x0000 (0 on send (unsigned short))
pkttype - x00 (0 on send (unsigned char))
halen - x06 (ETH_ALEN (unsigned char), from if_ether.h)
addr - xabcdef0123450000 (dest MAC address on the same segment as ifindex, (unsigned char[8]))
Edit:
Thanks to @meuh for some additional insight:
Ethernet packets are, by definition, big-endian or network order. Therefore, the data types of the sockaddr structure are big-endian, but as @meuh pointed out, my machine (x86) is little-endian. Therefore the bytes, for each member of the struct, need to be reversed. What caught me off guard is that the addr member of type char didn't need to be reversed. It's because addr is an array of char. Each element of the array, like the others struct members, needs to be reversed, but they are only one byte in length; but the data going into the array does not need to be reversed.