7 ms·
Fputc semantics for conforming C implementations
- masklinn 4y agoI’m a bit surprised that this needed an entire essay when the first section title (“no”) seems rather obvious? It feels a bit like when ddevault got pissy about “int” not meaning “codepoint”, when the minimum size of int is 16 bits (though in that case what they were doing was literally listed as UB in the docs).
- pdw 4y agoI don't feel it's obvious at all. Is it not surprising that you can't reliably store chars to a file using fgetc/fputc? OTOH I'm not surprised at all that embedded compilers play fast and loose with the standard.
- rsaxvc 4y ago> Is it not surprising that you can't reliably store chars to a file using fgetc/fputc? It was surprising until I needed I/O between two systems with different CHAR_BITs to work. Many systems need to access octet based field and communicate over octet based busses, and CHAR_BIT>8 machines are rare. If the entire char is serialized, interoperability becomes a much larger mess.
- masklinn 4y ago> I don't feel it's obvious at all. Is it not surprising that you can't reliably store chars to a file using fgetc/fputc? Chars 2 to 4 times larger than what the standard requires? Not really. It means one platform would be reading / writing 4 bytes (octets) at a time, at which point endianness rears its ugly head. That sounds a lot worse than clamping values to a byte.
- pantalaimon 4y agoI understand the reasoning. If you have a string each letter is an individual character. You wouldn't pack two ASCII letters into a a char just because your char is 16 bit. On such a platform, if you have the string "Hello" and would write the chars un-truncated you'd end up with "H\0e\0l\0l\0o\0" in the file. However, if you are not writing a string, but binary data, this would mean you now loose every 2nd 8-bit byte.
- theamk 4y agoIf you are writing binary data from memory, _and_ want to make sure any system can read it no matter what endianness it uses, you might need something like this: buf[0] = (data >> 24) & 0xFF; buf[1] = (data >> 16) & 0xFF; buf[2] = (data >> 8) & 0xFF; buf[3] = data & 0xFF; and if you want this code to work on all systems (including wide-char ones), you need to make sure fwrite truncates too.
- pwdisswordfish9 4y agoWhere is the ddevault bit?
- jzm2k 4y agoProbably this one: https://drewdevault.com/2020/09/25/A-story-of-two-libcs.html https://drewdevault.com/2020/09/25/A-story-of-two-libcs.html
- stephc_int13 4y agoThe C char type is an aberration. (and always has been) The simple fact that a char can be defined as unsigned is telling enough. C should have defined a byte type long ago. Overall, I think the whole C standard library aged poorly, the semantics, the somewhat cryptic and overly abbreviated naming style, all the corner cases with old types like char that was used instead of the missing byte type. I very rarely use C on top of the standard C library, I prefer using a framework or at least a single-header with a list of saner/more modern types and wrappers.
- apaprocki 4y agoExcept we’ve had stdint.h since C99 (23 years now), so we’ve had a byte type for a long time now. Are you saying C99 historically took too long, or that you don’t get what you need from C99?
- stephc_int13 4y agostdint.h was a huge improvement. But uint8_t is kind of ugly compared to "byte". I personally use : byte u16 u32 u64 i32 etc. and u32x4 f32x4 for vectorized/simd types Not perfect but a good-enough compromise that would not take long to understand for anyone reading my code.
- kabdib 4y agoI'm wading through a sea of third party projects right now that variously use uint8_t, u8, byte, tchar, BYTE, and endless variations that prepend one or two underscores for no apparent reason other than simple paranoia. Now, on to other types: Care to guess how many bits WORD is? (Trick question: It depends on the project you're in. You can imagine the adaptation layers when someone thinks that a bool is 32 bits). It's even more fun when they #define these types, rather than use typedef or "using". ("Remember, children, every time you use #define, God kills a startup"). These types don't make things safer, or in any way better. They are long-term damage. For the love of Schmoo, use the standards, I'm losing my hair.
- nuc1e0n 4y agoNo matter how frustrating this might be, it seems several points are clear from this post: 1. Implementations claiming to be 'standards conforming' can and do exhibit this behaviour. 2. The groups that wrote the standards do not, as a whole, assert that implementations which behave this way do not conform to the standard. 3. There seems to be consensus that the standard documents themselves as works of natural language produced by the members of the relevant standards bodies are open to a certain amount of interpretation. It would be good if members of the standards body could unify what the standard documents say with what is actually going on somehow. But it would be easy for TI to update their fputc to return the value that was actually written. Why don't they just do it? If the author feels the implementation is not standards conforming, why can't they write a shim to make it so?
- theamk 4y agoIf TI updates their fputc, then files written on TI microcontrollers would not be compatible with any other system, see other comments for plenty of examples. Most people who actually write for TI systems actually want to fputc to truncate. And the blog author got incensed right away without actually bothering to figure out _why_ the truncation happened or seeing what would be the implication of his proposed change on real-world programs.
- stephc_int13 4y agoIMHO the point of the author is backwards. unsigned char c = UCHAR_MAX; // e.g. 65'535 for CHAR_BIT = 16 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This is line that feels wrong for me. CHAR_BIT = 16 is a corner case, I don't even understand why it is allowed by the standard.
- beached_whale 4y agoThere is apparently a bunch of dsps and small hw that has char bit at 16 or 32. Someone would have to tell them that they’re compilers are no longer conforming
- stephc_int13 4y agoThis is where the standard should have been more strict. For historical reasons the original C types bit-width were not strongly defined as most hardware of the time had "exotic" registers and addresses by today standards. But it was a long time ago. It is reasonably safe to assume that 14bits ints and 21bits pointers are deprecated. Why would DSPs would need to change the bit-width of chars? We already have shorts and ints that are better candidates for 16bits and 32bits width. I would not mind if the C standard was made both stricter and simpler, and I maintain that the author is wrong, fputc (and all other IO libs depending on it) should not be modified to take into account this kind of absurd corner cases. Doing so will introduce more bugs and complexity that would have to be maintained.
- beached_whale 4y agoBecause char is loaded here. It is the minimum addressable bit size. So they need to do it because it is faster and cheaper to address larger chunks. The issue in the article is that calls to fputc/fwrite to a binary file will truncate to 8bits still on some of those platforms. A byte type that is always 8bits would be a better approach, i think. But c++ bungled this too, it is the size of a char still. I dont think it should exist on systems that cannot address 8bits
- 4y ago
- MrBuddyCasino 4y agoIncidentally, this is the exact same problem that we have in the political/legal world: sure, the words of the constitution are written with the unspoken expectation that they have a specific meaning, but in practise, whoever is in power can decide the meaning of the words to be what they want them to be: "As a starting point you need to understand that the C specification is not written in a rigorous language. It relies on making sense of natural language to draw any conclusions from it. If you insist that means you can just interpret it however you want, well, no one can stop you, but it's not a productive activity. The point is human consensus on what it means." [0] Or, how Alice In Wonderland put it: “When I use a word,” Humpty Dumpty said in rather a scornful tone, “it means just what I choose it to mean — neither more nor less.” “The question is,” said Alice, “whether you can make words mean so many different things.” “The question is,” said Humpty Dumpty, “which is to be master – – that’s all.” As for the (less interesting) technical argument, I think this one nails it on why it is illegal to truncate: " "Characters" here are the values representable in unsigned char as fputc is specified to write." [1] [0] https://twitter.com/RichFelker/status/1451040776855638019 https://twitter.com/RichFelker/status/1451040776855638019 [1] https://twitter.com/RichFelker/status/1451038290006663176 https://twitter.com/RichFelker/status/1451038290006663176
- amluto 4y agoI’ve never had the displeasure of programming on one of these large-char systems, but; > The fwrite() function shall write, from the array pointed to by ptr, up to nitems elements whose size is specified by size, to the stream pointed to by stream. So I guess fwrite writes nitems * size things of whatever size fputc writes. So, if fputc truncates, how large is the resulting file? Specifically, if I write ten chats (e.g. “Mic check\n”), do I get a ten-chat file? A ten-byte file? If I have two-byte chars and I write: {0xab, 0xab} Do I get a four-byte file containing 0xb, 0, 0xa, 0? Do I get a two-byte file? Does every file on the entire system consist of 50% zeros? Or do I end up with 0xb, 0xa, so the file is two bytes, filesystems have one-byte characters, but C’s CHAR_BIT is nonetheless 16? (The latter actually seems like it makes a tiny bit of sense on a system that doesn’t have 8-but hardware types at all.) But this is all pretty ridiculous, and none of the choices are really good. I can understand having bona fide 16-bit characters, but having this mismatch is nasty. To be clear, basically everything on old embedded designs is nasty. I have used some of these compilers, and they were unbelievably buggy messes.
- masklinn 4y ago> (The latter actually seems like it makes a tiny bit of sense on a system that doesn’t have 8-but hardware types at all.) > But this is all pretty ridiculous, and none of the choices are really good. I can understand having bona fide 16-bit characters, but having this mismatch is nasty. I’d say the latter (and the behaviour observed by TFA) is the only sensible behaviour, otherwise it's plain not possible to do IO portably, instead you would have to write different codepaths based on CHAR_BIT. Not only that but: > Do I get a four-byte file containing 0xb, 0, 0xa, 0? Do I get a two-byte file? There's actually a third choice because multibyte IO means endianness becomes a factor. So given char[]{0xab, 0xab} the output of writing memory contents literally with CHAR_BIT=16 could be 0x00 0xAB 0x00 0xAB or 0xAB 0x00 0xAB 0x00
- fefe23 4y agoI gave up after two pages of the article not getting to the point.
- forthorbor 4y agoThis is a great article, but C is full of ways to easily make mistakes with memory. I think with rust being more mature everyone should consider using it, or another memory safe language, instead of starting to write new programs using C.
- gavinhoward 4y agoI am making a personal C standard library of sorts, and I made the decision to only use the low-level I/O routines on each platform and implement buffering and file semantics myself. I did this to make it easier to have an implementation that works the same on POSIX and Windows. (I wanted to use the native CreateFile(), WriteFile(), etc. on Windows.) This article justifies that decision in a way I never thought would happen. I didn't think I would have other reasons for doing so. Now I do.
- rzimmerman 4y ago> code full of #ifdefs is not portable code - it’s code that has been ported a lot Definitely true - the more preprocessor magic you rely on, the harder it is to figure out what’s actually going into the compiler.
- asveikau 4y ago> unsigned char cread = (unsigned char)fgetc(f); Don't do this. The return type of fgetc is int for a reason. When you do this, you will have no ability to distinguish between EOF, which is typically -1, and (unsigned char)EOF, which is typically 0xff. So if the user types '\xff' or has some binary data you may treat it as end of file. In practice that issue is way more common than the purpose of this article, a theoretical compiler where somebody has chosen to make char exceed 8 bits, which in the 21st century I would classify as crazy town.
- anonymoushn 4y agoThe fine author wrote that they spoke to numerous engineers who have been writing code targeting these compilers for decades though?
- tinus_hn 4y agoThis is just pretty bad design, and it means if char is 16 bits and so is int, which as far as I can tell is allowed, the interface is unusable.
- kevin_thibedeau 4y agoThe systems where this is the case aren't intended for text processing.
- anonymoushn 4y agothis doesn't mean that fwrite should only write every 2nd or every 4th byte, when you give it a pointer to some non-text data
- asveikau 4y agoYou've just illustrated one of the reasons I wrote that it would be crazy town to make such a choice. The C standard and POSIX are full of things like this. Areas where an implementation could make different choices, but actually making certain choices would be very foolish, so by and large people do not. Re-reading the article, it seems like Texas Instruments has made such a choice. I think it'd be quite challenging to find others. Edit: i found this documentation from TI confirming this. https://www.ti.com/document-viewer/lit/html/SPRAC71A/GUID-3FF7C096-6819-499B-B1F7-629908E57DE6#TITLE-SPRAC71SPRAB896205 https://www.ti.com/document-viewer/lit/html/SPRAC71A/GUID-3F... "There are no 8-bit objects on C28x devices. This presents unique challenges for implementing the ELF object file format on C28x devices." Int is also 16 bits.
- patrakov 4y agoPrevious "discussion": https://news.ycombinator.com/item?id=31077574 https://news.ycombinator.com/item?id=31077574 As it has only one comment, let me copy-paste it in full, below. --- I have read the article, and my first reaction is to disagree with the conclusions. The whole problem stems from the fact that the units in which the data is accessed by the ("strange") CPU and by the external storage can be different. Let's suppose that the CPU-character is 16-bit wide (i.e. that the CPU can't address or process 8-bit data units individually), while the storage-character is 8-bit wide. In that case, both the behavior of writing each CPU-character as two storage-characters, _and_ the behavior of writing only the 8 bits of each CPU-character to storage, might make sense, for different applications. This is not really about truncation, this is about choosing an interleaved or non-interleaved representation of the storage content in memory as the source layout. And yes, for the representation where each storage-character is represented by one CPU-character, it is logical that the higher 8 bits can't matter, must be ignored, and so the program can return 2 in this case. The real bug here is the attempt to stipulate one and the same storage representation for all applications. This should ideally be a flag to fopen(), just like we have a "b" flag to denote binary streams on some systems. I think it is too late to rule on the default, though.
- bawolff 4y agoI'm not really a c programmer, but in terms of weird c behaviour, this doesn't sound that insane (relative to usual c craziness). So if i understand, a char type is allowed to be bigger than a "character", but it will still get truncated to be a character when written. Sounds reasonable enough to me if you have some weird architecture .